iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
自我挑戰組

賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁系列 第 21 篇

Day 21:幫診所裝一台會跳動的心電圖機

  • 分享至 

  • xImage
  •  

前言

昨天解決完呼吸過場的語言同步問題之後,我盯著那個圓圈動畫看了很久。

單純地縮放、變色,一縮一放,看久了其實有點像……呼吸球。嗯,它確實就是設計來模擬呼吸的,所以某種意義上算是成功了。

但我心裡還是覺得少了什麼。

既然都叫「賽博紓壓診所」了,使用者答題的時候,螢幕角落怎麼可以沒有一條會跳動的綠線?那種滴、滴、滴,忽然一個尖峰竄上去再滑下來的畫面,才是真正會讓人覺得「我正在被監測」的儀式感。

所以今天要做的東西就是:

打造一個即時心電圖(ECG)模擬器,而且波形要隨著測驗的進度跳動。

答題答得越快、進度推進得越急,波形就跳得越劇烈;快要答完的時候,振幅拉到最高,有一種「要被榨乾」的感覺。是裝飾,但我就是想要這個樣子。


核心觀念:心電圖波形,其實就是一條被拉長再壓扁的線

一開始我以為畫心電圖會很難,畢竟真正的 ECG 波形有 P 波、QRS 複合波、T 波,一堆專有名詞。

但心裡又想著:我不是要做醫療儀器,我是要做一個「看起來像心電圖」的視覺特效。

心態的轉變,看待事情的角度也不同。

真正的心電圖波形,簡化到極致大概長這樣:

一條平線
    ↓
突然一個小凹陷(往下一點點)
    ↓
緊接著一個又高又窄的尖峰(往上衝一大截)
    ↓
再一個小凹陷(往下)
    ↓
一個圓滑的小丘(往上一點點)
    ↓
回到平線
    ↓
重複

我把這個波形拆成一組固定的「座標點」,每個點記錄「往右移動多少、往上或往下偏移多少」:

const beatPattern = [
    { x: 0,  y: 0 },
    { x: 10, y: 0 },
    { x: 12, y: -5 },   // 小凹陷
    { x: 14, y: 40 },   // 尖峰往上衝
    { x: 16, y: -20 },  // 尖峰往下甩
    { x: 18, y: 0 },
    { x: 22, y: -8 },   // 小丘
    { x: 26, y: 0 },
    { x: 40, y: 0 }
];

這一組點,就是「一次心跳」。接下來要做的事情很單純:用 Canvas 把這些點連成線,並且不斷把新的一拍接到畫面右邊,舊的從左邊推出去。

這種「畫面從右邊生出新東西、舊東西往左邊移出去消失」的效果,實作上我選了一個很有效率的做法:不是移動線條本身,而是移動時間軸的取樣起點。

offset += speed;

每一幀,把 offset 往前推進一點點,畫的時候用 offset 去決定現在要從波形的哪一段開始取樣,看起來就像整條線在往左跑。

至於「跟著測驗進度跳動」這件事,邏輯也不複雜:進度條走到哪,就拿那個百分比去控制心跳的速度跟振幅。

const speed = 2 + progress * 6;      // 進度越高,線跑得越快
const amplitude = 1 + progress * 1.5; // 進度越高,波峰越誇張

題目答得越多,這個數字就越接近 1,波形也就跳得越激烈。


動手寫 Code:心電圖監測器

今天做一個獨立的示範,用一顆「下一題」按鈕模擬測驗進度推進,看心電圖怎麼跟著變化:

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:即時心電圖模擬器</title>
<style>
    body {
        font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
        background: #0d1117;
        display: flex;
        justify-content: center;
        align-items: center;
        min-height: 100vh;
        margin: 0;
        padding: 20px;
    }
    .card {
        background: #161b22;
        padding: 25px;
        border-radius: 20px;
        box-shadow: 0 12px 30px rgba(0,0,0,0.5);
        width: 100%;
        max-width: 460px;
        text-align: center;
        color: #e6edf3;
    }
    h2 { color: #4ade80; margin: 0 0 5px; }
    .subtitle { color: #8b949e; font-size: 0.85rem; margin-bottom: 15px; }

    .monitor {
        background: #000;
        border: 2px solid #238636;
        border-radius: 10px;
        overflow: hidden;
        margin-bottom: 15px;
    }
    canvas { display: block; width: 100%; height: 140px; }

    .stats {
        display: flex;
        justify-content: space-between;
        font-size: 0.85rem;
        color: #4ade80;
        margin-bottom: 15px;
        padding: 0 4px;
    }

    .progress-track {
        height: 10px;
        background: #21262d;
        border-radius: 5px;
        overflow: hidden;
        margin-bottom: 15px;
    }
    .progress-fill {
        height: 100%;
        width: 0%;
        background: linear-gradient(90deg, #4ade80, #f87171);
        transition: width 0.5s ease;
    }

    .btn {
        background-color: #238636;
        color: white;
        border: none;
        border-radius: 50px;
        padding: 12px 30px;
        font-size: 1rem;
        cursor: pointer;
        width: 100%;
        transition: 0.2s;
    }
    .btn:hover { background-color: #2ea043; transform: translateY(-2px); }
    .ghost { background-color: #30363d; margin-top: 8px; }
    .ghost:hover { background-color: #484f58; }
</style>
</head>
<body>

    <div class="card">
        <h2>💓 腦波監測儀</h2>
        <p class="subtitle">波形會跟著測驗進度一起加劇</p>

        <div class="monitor">
            <canvas id="ecg" width="500" height="140"></canvas>
        </div>

        <div class="stats">
            <span id="bpm">60 bpm</span>
            <span id="progressText">進度 0%</span>
        </div>

        <div class="progress-track">
            <div class="progress-fill" id="progressFill"></div>
        </div>

        <button class="btn" onclick="nextQuestion()">下一題(模擬作答)</button>
        <button class="btn ghost" onclick="reset()">重新開始</button>
    </div>

    <script>
        const canvas = document.getElementById("ecg");
        const ctx = canvas.getContext("2d");

        // ---------- 今天的重點:一拍的波形座標 ----------
        const beatPattern = [
            { x: 0,  y: 0 },
            { x: 10, y: 0 },
            { x: 12, y: -5 },
            { x: 14, y: 40 },
            { x: 16, y: -20 },
            { x: 18, y: 0 },
            { x: 22, y: -8 },
            { x: 26, y: 0 },
            { x: 40, y: 0 }
        ];
        const beatWidth = 40; // 一拍在時間軸上占多寬

        const TOTAL_QUESTIONS = 5;
        let answered = 0;
        let offset = 0;
        let animId = null;

        // ---------- 進度決定速度與振幅 ----------
        function getProgress() {
            return answered / TOTAL_QUESTIONS;
        }

        function getSpeed() {
            return 2 + getProgress() * 6;
        }

        function getAmplitude() {
            return 1 + getProgress() * 1.5;
        }

        // 在波形上任意取一個 x 位置的 y 值(在兩個定義點之間用直線內插)
        function sampleBeat(x) {
            const localX = x % beatWidth;
            for (let i = 0; i < beatPattern.length - 1; i++) {
                const a = beatPattern[i];
                const b = beatPattern[i + 1];
                if (localX >= a.x && localX <= b.x) {
                    const t = (localX - a.x) / (b.x - a.x || 1);
                    return a.y + (b.y - a.y) * t;
                }
            }
            return 0;
        }

        function draw() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);

            // 背景格線,營造醫療儀器感
            ctx.strokeStyle = "rgba(74, 222, 128, 0.08)";
            for (let gx = 0; gx < canvas.width; gx += 20) {
                ctx.beginPath();
                ctx.moveTo(gx, 0);
                ctx.lineTo(gx, canvas.height);
                ctx.stroke();
            }

            const midY = canvas.height / 2;
            const amplitude = getAmplitude();

            ctx.beginPath();
            ctx.strokeStyle = "#4ade80";
            ctx.lineWidth = 2;
            ctx.shadowColor = "#4ade80";
            ctx.shadowBlur = 6;

            for (let x = 0; x < canvas.width; x++) {
                const sampleX = x + offset;
                const y = midY - sampleBeat(sampleX) * amplitude;
                if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
            }
            ctx.stroke();
            ctx.shadowBlur = 0;

            offset += getSpeed();
            animId = requestAnimationFrame(draw);
        }

        // ---------- 模擬測驗進度推進 ----------
        function nextQuestion() {
            if (answered >= TOTAL_QUESTIONS) return;
            answered++;

            const progress = getProgress();
            document.getElementById("progressFill").style.width = (progress * 100) + "%";
            document.getElementById("progressText").innerText = `進度 ${Math.round(progress * 100)}%`;
            document.getElementById("bpm").innerText = `${Math.round(60 + progress * 60)} bpm`;
        }

        function reset() {
            answered = 0;
            document.getElementById("progressFill").style.width = "0%";
            document.getElementById("progressText").innerText = "進度 0%";
            document.getElementById("bpm").innerText = "60 bpm";
        }

        draw();
    </script>
</body>
</html>

再存成 .html 打開,先看一下靜止狀態的心跳線,再連續按幾次「下一題」,你會明顯看到線跑得越來越快,波峰也越跳越高,跟真的壓力緊繃起來的感覺還挺像的。


踩過的小細節

1. 為什麼用「移動取樣起點」而不是真的移動線條上的每個點?

一開始我想的是,每一幀把陣列裡所有點的 x 座標都減一,模擬線往左移動。寫完才發現,陣列會一直增長(新的點要加進來),舊的點要定期砍掉,還要處理陣列邊界,邏輯複雜許多。

後來想通:我不需要真的有一堆點在動,我只需要在每一幀,問「現在這個 x 座標,對應到波形的哪個位置」,然後把答案畫出來就好。

波形本身是固定不變的 beatPattern,變的只是 offset 這個數字,用它去決定「現在看到的是波形的哪一段」。畫面上的每一個像素都是即時算出來的,不需要儲存任何歷史資料邏輯乾淨。

2. sampleBeat() 裡面那段內插在幹嘛?

beatPattern 裡只定義了幾個關鍵點,點跟點之間其實是空的。如果只是「找到離 x 最近的點」,畫出來的線會有明顯的鋸齒感,不夠平滑。

內插做的事情是:如果 x 落在兩個定義點中間,就按照比例去抓一個介於兩者之間的 y 值。這樣即使只定義了 9 個點,畫出來也會是一條連續的折線,不會卡卡的。

3. localX = x % beatWidth 這行是什麼意思?

波形不是只跳一次,是要重複很多次的。beatWidth 是「一拍佔多寬」,用 %(取餘數)把任意大的 x 座標,折回「這一拍內部的相對位置」。這樣不管現在跑到第幾拍,都能用同一組 beatPattern 重複套用,不用為每一拍都準備一份資料。

4. 為什麼振幅跟速度的計算要寫成 2 + progress * 6,不是直接 progress * 8?

如果直接用 progress * 8,那在測驗剛開始、progress 還是 0 的時候,速度會變成 0,畫面上的線就完全靜止了,看起來像當機,而不是「平靜的心跳」。

加上一個基礎值(像是 2),代表「就算還沒開始答題,心跳也是會動的」,只是比較平緩而已,之後才隨著進度逐漸加快。這個小細節讓整個效果在測驗一開始就有生命力,不會一開場就是個死畫面。


開發心得與設計考量

今天讓我體會到一件事:做一個「看起來專業」的視覺效果,不代表邏輯要跟著一樣複雜。

一開始我滿擔心心電圖這種東西會牽扯到很多生理訊號的知識,結果做完才發現,核心完全是純數學:一組固定的座標點、一個不斷往前推進的位移量、再加一點點內插計算而已。難度沒有想像中高,反而是「怎麼讓它看起來夠真實」這件事花了比較多時間,像是要不要加格線背景、線條要不要加發光效果,這些調整比演算法本身還花心思。

另外,今天「用進度去驅動視覺變化」這個做法,也讓我想起之前做六維度歸一化的時候,核心都是同一招:找到一個 0 到 1 之間的比例,拿這個比例去控制某個看得見的東西。只是那時候控制的是雷達圖上的長度,今天控制的是線條跑動的速度跟振幅。


今日結語與明日預告

今天我們用一組固定的波形座標,加上不斷推進的採樣量,產生了一個會隨監測跳動的即時心電圖監測儀,讓回答問題這件事終於有了「正在被」的賽博感。

寫完這篇之後我往回滑瀏覽這幾天的文章,才意識到一件事:從模擬合成開始,到今天的心電圖,這個階段其實已經悄悄走了過去。

點擊音、破裂音、求簽音、四種語言切換、呼吸過場的語言同步、現在又多了一個會跳動的心電圖機——這些東西單獨看都只是一個小功能,但串起來到底看,整間練習已經從“安靜的表格”變成了一個會發出聲音、會說話、會呼吸、連心跳都看得見的東西。

所以,第22天,我想停下來整理:回顧第二階段,為什麼每個小細節都值得花這麼長時間去摳。

先喘口氣,到底看看這條路是怎麼走過來的!


上一篇
Day 20:連呼吸都要會說四種語言
下一篇
Day 22:第二階段總結——這十四天到底蓋出了什麼!
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言